Test the immutability of TypeScript types.
Donate
Any donations would be much appreciated. 😄
Installation
npm install is-immutable-type
yarn add is-immutable-type
pnpm add is-immutable-type
Usage
import { getTypeImmutability, Immutability, isReadonlyDeep, isUnknown } from "is-immutable-type";
import type ts from "typescript";
function foo(checker: ts.TypeChecker, node: ts.Node) {
const nodeType = checker.getTypeAtLocation(node);
const constrainedNodeType = checker.getBaseConstraintOfType(nodeType);
const immutability = getTypeImmutability(checker, constrainedNodeType);
if (isUnknown(immutability)) {
console.log("`immutability` is `Unknown`").
} else if (isReadonlyDeep(immutability)) {
console.log("`immutability` is `ReadonlyDeep` or `Immutable`").
} else {
console.log("`immutability` is `ReadonlyShallow` or `Mutable`").
}
}
Tip: You can also use comparator expressions (such as >
and <
) to compare
Immutability
.
Note: Immutability.Unknown
will always return false
when used in a
comparator expression. This includes ===
- use isUnknown()
if you need to
test if a value is Unknown
.
Immutability
Definitions
Immutable
: Everything is deeply read only and nothing can be modified.ReadonlyDeep
: The data is deeply immutable but methods are not.ReadonlyShallow
: The data is shallowly immutable, but at least one deep value is not.Mutable
: The data is shallowly mutable.Unknown
: We couldn't determine the immutability of the type.
Note: Calculating
is used internally to mean that we are still calculating the
immutability of the type. You shouldn't ever need to use this value.
Overrides
Sometimes we cannot correctly tell what a type's immutability is supposed to be
just by analyzing its type makeup. One common reason for this is because methods
may modify internal state and we cannot tell this just by the method's type. For
this reason, we allow types to be overridden.
To override a type, pass an overrides
array of all the override
objects you
want to use to your function call.
You can either override a type by name
or by a regex pattern
.
You must specify a to
property with the new immutability value that should be
used.
Additionally you may specify a from
property which will make it so the
override will only be applied if the calculated immutability is between the
to
and from
values (inclusively).
Example 1
Always treat ReadonlyArray
s as Immutable
.
[{ name: "ReadonlyArray", to: Immutability.Immutable }]
Example 2
Treat ReadonlyArray
s as Immutable
instead of ReadonlyDeep
. But if the
instance type was calculated as ReadonlyShallow
, it will stay as such.
[{ name: "ReadonlyArray", to: Immutability.Immutable, from: Immutability.ReadonlyDeep }]
Default Overrides
By default the following types are overridden to be Mutable
:
Map
Set
Date
URL
URLSearchParams
If you know of any other TypeScript types that need to be
overridden, please open an issue.
Note: When providing custom overrides, the default ones will not be used. Be
sure to include the default overrides in your custom overrides if you don't want
to lose them. You can obtain them with getDefaultOverrides()
.
Another Use for Overrides
Currently due to limitations in TypeScript, it is impossible to write a utility
type that will transform any given type to an immutable version of it in all
cases. (See this issue)
One popular implementation of such a utility type is
type-fest's
ReadonlyDeep
type. If you want this library to treat types wrapped in ReadonlyDeep
as
immutable regardless, you can provide an override stating as such.
[{ pattern: /^ReadonlyDeep<.+>$/u, to: Immutability.Immutable }]
Note here the inclusion of ReadonlyObjectDeep
, this comes from the internal
workings of ReadonlyDeep
.
Limitations (when it comes to overrides)
Primitives
Currently we cannot override primitives or aliases of primitives.
For example, if we have the following code:
type A = string;
type B = A;
We cannot override A
, B
or string
here.
The reason we cannot override the aliases is because, internally, TypeScript
discards both A
and B
and just uses string
in their place.
Caching
By default we use a global cache to speed up the calculation of multiple types'
immutability. This prevents us from needing to calculate the immutability of
the same types over and over again.
However, this cache assumes you are always using the same type checker. If you
need to use multiple (such as in a testing environment), this can lead to
issues. To prevent this, you can provide a custom cache (by passing a WeakMap
)
to be used or have a temporary cache be used (by passing false
).
Making ReadonlyDeep
types Immutable
Many types that you may expect to be immutable (including those defined
internally by TypeScript itself) are not written with immutable methods and thus
are not reported as immutable by this library. Luckily it is quite easy to make
such type immutable. Just simply wrap them in Readonly
.
Example
These types are ReadonlyDeep
:
type Foo = ReadonlySet<string>;
type Bar = ReadonlyMap<string, number>;
While these types are Immutable
:
type Foo = Readonly<ReadonlySet<string>>;
type Bar = Readonly<ReadonlyMap<string, number>>;
However it should be noted that this does not work for arrays. TypeScript will
treat Readonly<Array<T>>
exactly the same as ReadonlyArray<T>
and
as a consequence Readonly<ReadonlyArray<T>>
is also treated the same.
In order to get around this, we need to slightly tweak the Readonly
definition
like so:
type ImmutableShallow<T extends {}> = {
readonly [P in keyof T & {}]: T[P];
};
Now the follow will correctly be marked as Immutable
.
type Foo = ImmutableShallow<readonly string[]>;
type Bar = ImmutableShallow<ReadonlyArray<string>>;
Note: ImmutableShallow<string[]>
will also be marked as immutable but the type
will still have methods such as push
and pop
. Be sure to pass a readonly
array to ImmutableShallow
to prevent this.